Skip to content

feat(runtime):增加工具调用重复循环熔断机制#303

Merged
phantom5099 merged 16 commits into
1024XEngineer:mainfrom
phantom5099:main
Apr 15, 2026
Merged

feat(runtime):增加工具调用重复循环熔断机制#303
phantom5099 merged 16 commits into
1024XEngineer:mainfrom
phantom5099:main

Conversation

@phantom5099

Copy link
Copy Markdown
Collaborator

描述

当前我们在处理 Agent 的自主执行时,会遇到模型陷入“复读机”状态的情况——即连续多次调用同一个工具,且传入完全相同的参数,导致白白消耗 Token 甚至引发死循环。
本 PR 引入了“重复调用判定与熔断机制”,用于在模型陷入重复死循环时尽早发出自愈警告,并在达到上限时强制打断运行。

主要改动

  1. 配置层 (internal/config):
    • RuntimeConfig 中新增了 MaxRepeatCycleStreak 配置项(默认值为 3),与现有的 MaxNoProgressStreak 并列。
  2. 控制面 (internal/runtime/controlplane/progress.go):
    • ProgressState 新增 LastSignature 字段,用于记录上一轮工具调用的签名特征。
    • 更新 ApplyProgressEvidence,当检测到当前签名与上一轮一致时,累加 RepeatCycleStreak(重复循环计数)。
  3. 运行时 (internal/runtime/run.go & internal/runtime/run_lifecycle.go):
    • 新增 computeToolSignature 函数,对传入的工具及参数做 JSON 序列化规范化(消除空格、换行等格式干扰)并计算 SHA256 哈希值。
    • 新增 ErrRepeatCycleLimit 错误定义。
    • 当重复次数达到 MaxRepeatCycleStreak - 1 时,向 System Prompt 注入 selfHealingRepeatReminder,提醒模型停止复读。
    • 当重复次数达到 MaxRepeatCycleStreak 时,直接返回错误中断运行。
  4. 测试 (internal/runtime):
    • 调整了 runtime_test.go 中原有的死循环测试用例,在 mock 参数中加入了动态自增字段,以防止新机制误杀原有的无进展 (NoProgress) 测试链路。

核心机制原理解析

新增的逻辑构成了一套 “检测 -> 警告 -> 熔断” 的完整防御机制,具体运作流程如下:

1. 计算操作签名 (Signature)

每次模型回复并要求调用工具时,run.go 会调用新加的 computeToolSignature 方法。
这个方法会遍历本次的所有工具调用(比如 filesystem_edit),并尝试解析它的 JSON 参数,再重新规范化序列化

  • 目的:不论模型输出时带了换行还是被压缩成了单行,只要 JSON 结构语义相同,规范化后都能得到稳定的字符串表现,进而计算出稳定的 SHA-256 哈希值作为这一轮的**“行为签名”**。这样可以有效防止因排版差异绕过死循环检测。

2. 对比并积累连击 (Streak Tracking)

在工具执行完毕后,调用 ApplyProgressEvidence

  • 引擎会将本次的“行为签名”与上一轮记录的 LastSignature 进行对比。
  • 如果有尝试调用工具,并且签名完全一样,引擎判定模型陷入了重复操作,此时 RepeatCycleStreak (重复连击数) 增加。
  • 如果签名发生变化,或者成功取得了实质性进展,则计数器清零。

3. 触发自愈提醒 (Self-Healing)

在准备下一轮询问模型(prepareTurnSnapshot)时,引擎会检查连击数。
假设配置的最高忍耐次数(MaxRepeatCycleStreak)为 3:

  • 当模型连续 2 次 发出同样的工具调用请求时,引擎会在发给模型的 System Prompt 尾部附加一段强力警告 (selfHealingRepeatReminder),提醒模型停止重复无效动作并尝试更换参数或工具,给予模型一次自我纠正的机会。

4. 强制熔断 (Circuit Breaker)

如果模型未能自我纠正,在第 3 次 依然发起了完全相同的请求:
引擎会在主循环中进行拦截并抛出 ErrRepeatCycleLimit 错误,强制中断运行。
这不仅防止了陷入无限计费的 Token 黑洞,也能够及时将控制权归还给用户。

wynxing and others added 5 commits April 15, 2026 10:03
fennoai[bot]

This comment was marked as outdated.

@phantom5099

Copy link
Copy Markdown
Collaborator Author

/code 修复编译错误以及review提到的问题

@fennoai

fennoai Bot commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

处理结果

已按触发指令完成“修复编译错误以及 review 提到的问题”。

变更摘要

  • 修复配置校验兼容性回归:internal/config/runtime.go
    • MaxRepeatCycleStreak 改为仅在 < 0 时报错,0 保持可用并由默认值补齐。
  • 补充配置层测试:internal/config/runtime_test.go
    • 增加 MaxRepeatCycleStreakCloneApplyDefaultsValidate 的覆盖。
  • 补充 runtime 回归测试:internal/runtime/runtime_progress_test.go
    • 新增测试覆盖重复调用达到阈值返回 ErrRepeatCycleLimit
    • 新增测试覆盖阈值前一轮注入 selfHealingRepeatReminder

验证结果

  • go test ./internal/config -run TestRuntimeConfig -count=1 通过
  • go test ./internal/runtime -run 'TestProgressStreakStopsRun|TestRepeatCycleStreakStopsRunAndInjectsReminder' -count=1 通过
  • go test ./... 通过

提交与推送

未完成项:无。

View job run

xgopilot and others added 3 commits April 15, 2026 05:04
Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com>
- 将 Validate 中 MaxRepeatCycleStreak 判断由 < 0 回退为 <= 0,与
  ApplyDefaults / resolveRepeatCycleStreakLimit / 内联兜底三处保持一致
- 在 TestRuntimeConfigApplyDefaults 补充负值回退测试(-1 被替换为默认值)
- 在 TestRuntimeConfigValidate 将有效测试入参从 0 改为 1,并将 0 纳入
  无效值覆盖列表,覆盖 <= 0 分支
- 在 TestRepeatCycleStreakStopsRunAndInjectsReminder 补充
  EventStopReasonDecided 事件断言(StopReasonError + detail 字段),
  与 TestProgressStreakStopsRun 保持对称覆盖

Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com>
fix(runtime): 修复编译错误并补齐重复循环回归测试
@codecov

codecov Bot commented Apr 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.08696% with 41 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/skills.go 82.05% 12 Missing and 9 partials ⚠️
internal/context/source_skills.go 90.90% 5 Missing and 5 partials ⚠️
internal/session/skill_activation.go 91.66% 3 Missing and 3 partials ⚠️
internal/runtime/run.go 91.30% 2 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@phantom5099

Copy link
Copy Markdown
Collaborator Author

/code 提高测试覆盖率

@fennoai

fennoai Bot commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

完成情况

已按你的要求提升本 PR 的测试覆盖率,并将变更推送到 phantom5099:main(PR #303 已更新)。

本次改动

  • 新增测试:internal/runtime/runtime_progress_test.go
  • 补充了重复调用熔断相关分支覆盖,包含:
  1. computeToolSignature 的 JSON 规范化一致性与非法 JSON 降级路径
  2. prepareTurnSnapshot 在空 system prompt 下的重复循环提醒注入
  3. 重复提醒与无进展提醒同时触发时的优先级(重复提醒优先)
  4. resolveNoProgressStreakLimit / resolveRepeatCycleStreakLimit 默认值兜底分支

验证结果

  • go test ./internal/runtime/... -cover
    • internal/runtime 覆盖率:95.9% -> 96.3%/96.4%
  • go test ./internal/config/... -cover(通过)

未执行

  • 未新增功能代码,仅补测试提升覆盖率。

View job run

xgopilot and others added 6 commits April 15, 2026 07:30
Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com>
feat(context): 接入 session 级 skills 注入并修正 runtime 降级语义
…lization

Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com>
…ool-result-projection

fix(context): 修复 metadata-only 工具结果语义丢失
@phantom5099

Copy link
Copy Markdown
Collaborator Author

/review

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one noteworthy issue in the repeat-cycle breaker logic.

The current implementation only increments RepeatCycleStreak when a round has progress evidence (len(records) > 0). That means repeated failing tool calls with identical arguments never count toward repeat-cycle detection, so the new circuit breaker can be bypassed in the exact “stuck loop” scenario it is meant to stop.

next := state.LastScore
isRepeated := false

if len(records) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRepeated is gated by len(records) > 0, and when len(records)==0 you also clear LastSignature below. This prevents repeat-loop detection for repeated failing tool calls (same tool + same args), because failed rounds never accumulate RepeatCycleStreak.

Impact: loops with identical failing calls are only limited by MaxNoProgressStreak, not MaxRepeatCycleStreak, which weakens the new breaker.

Suggestion: compare currentSignature against LastSignature whenever a tool call was attempted (signature non-empty), independent of success evidence; keep/reset signature based on whether a tool-call turn occurred, not whether evidence was positive.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 1 个需要修正的问题,核心在于重复熔断判定与 PR 描述的行为不一致。

next := state.LastScore
isRepeated := false

if len(records) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRepeated 只在 len(records) > 0 时才会计算,且 records 目前仅由“工具成功且非 error”生成。这会导致“同一工具+同一参数的连续失败调用”不会进入 RepeatCycleStreak,与 PR 描述中“重复调用即判定并熔断”不一致。建议将重复判定与 records 解耦:只要本轮存在 tool call 且签名与 LastSignature 相同就累加 RepeatCycleStreak,再单独用 records 决定 NoProgressStreak/ScoreDelta

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 2 个会影响重复循环熔断行为的逻辑问题,建议在合入前修正。

Comment thread internal/runtime/run.go
@@ -143,13 +177,25 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) {
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

当前重复循环判定依赖 evidence,而 evidence 仅在非错误 tool message 时才会写入(见同一逻辑块)。这会让“相同工具 + 相同参数 + 持续失败”的循环不计入 RepeatCycleStreak,只能走 NoProgressStreak,从而绕过重复循环提醒/熔断语义。建议将“重复调用检测”与“调用是否成功”解耦:只要本轮发生工具调用且签名与上轮一致,就应累计重复 streak。

Comment thread internal/runtime/run.go
repeatLimit = config.DefaultMaxRepeatCycleStreak
}

if repeatStreak >= repeatLimit {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里存在阈值语义偏移:repeatStreak 是在“与上一轮相同”时才递增,因此 MaxRepeatCycleStreak=3 时实际会在第 4 次相同调用才熔断(提醒也会晚一轮)。这与 PR 描述中的“第 3 次熔断 / 第 2 次提醒”不一致。建议统一计数语义或调整比较条件,避免 off-by-one 行为。

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deduplicated review findings (2):

  1. Repeat-cycle detection currently misses repeated failing calls with identical signatures.
  2. Repeat threshold behavior appears off-by-one versus the PR description (3 configured => break on 4th identical call).

next := state.LastScore
isRepeated := false

if len(records) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRepeated is currently gated by len(records) > 0, while failed tool-call rounds produce empty records. Combined with clearing LastSignature on len(records)==0, identical failing calls never increment RepeatCycleStreak, so this breaker can be bypassed in repeated-failure loops. Consider decoupling repeat-signature tracking from success evidence.

Comment thread internal/runtime/run.go
repeatLimit = config.DefaultMaxRepeatCycleStreak
}

if repeatStreak >= repeatLimit {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given current counting semantics, MaxRepeatCycleStreak=3 breaks on the 4th identical call (and reminder arrives one turn later than the PR description). If intended behavior is "warn on 2nd, break on 3rd identical call", adjust threshold comparison or streak increment timing to align implementation with documented behavior.

@phantom5099

Copy link
Copy Markdown
Collaborator Author

/simplify

@fennoai

fennoai Bot commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

完成结果

已按指令完成:读取历史评论并处理值得修复的问题、再进行代码简化,并已提交推送。

已处理的 review 问题

  • 修复 RepeatCycleStreak 统计仅依赖成功证据的问题:现在只要本轮发生 tool call,就会基于签名参与重复连击统计,失败重复调用也会累计。
  • 修复重复熔断阈值语义偏移(off-by-one):MaxRepeatCycleStreak=3 现在在第 3 次相同调用熔断(而非第 4 次)。

代码与测试改动

  • internal/runtime/controlplane/progress.go
  • internal/runtime/controlplane/progress_test.go
  • internal/runtime/runtime_progress_test.go

主要调整:

  • ApplyProgressEvidence 改为按“是否发生工具调用”维护 RepeatCycleStreak/LastSignature,与成功证据解耦。
  • 保持 NoProgressStreakScoreDelta 语义不变,并补全失败重复场景回归。
  • 运行时测试新增/增强:
    • 重复熔断在第 3 次触发的断言(调用次数断言)
    • 失败重复调用也会触发重复熔断
    • 仅验证 no-progress 的用例改为动态参数,避免被重复熔断误拦截
  • 按要求调用了 code-simplifier 子代理并吸收可读性优化(断言去重、条件分支简化)。

验证

  • go test ./internal/runtime/controlplane ./internal/runtime -count=1 通过
  • go test ./... -count=1 通过

提交与推送

未执行

  • 未修改与本次触发指令无关的其他历史建议。

View job run

xgopilot and others added 2 commits April 15, 2026 14:38
Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com>
fix(runtime): align repeat-cycle breaker semantics and tests
@phantom5099 phantom5099 merged commit 3125d08 into 1024XEngineer:main Apr 15, 2026
2 checks passed
@fennoai fennoai Bot mentioned this pull request Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants